Add multi-camera support with minimap example#1310
Conversation
Add screenX, screenY, zoom, autoResize, isDefault, worldView, setViewport, worldProjection and screenProjection to Camera2d for proper multi-camera rendering. Add visibleInAllCameras to Renderable for per-camera floating element filtering. Implement setProjection in CanvasRenderer to properly apply ortho projection as canvas 2D transform. Auto-flush in WebGL setProjection to prevent batched quads from rendering with wrong projection. Add minimap camera example to platformer with viewport highlight and player marker. Includes 95+ new unit tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR introduces first-class multi-camera support in Camera2d (viewport placement + zoom) and updates rendering logic so world/floating elements can be filtered per camera, with an updated platformer example demonstrating a minimap camera.
Changes:
- Extend
Camera2dwithscreenX/screenY,zoom,autoResize,isDefault,worldView,setViewport(), and per-camera projection matrices. - Add
visibleInAllCamerastoRenderableand updateContainer/ImageLayerrendering to support per-camera floating element behavior. - Implement Canvas/WebGL projection switching behavior and add a minimap camera example; add/extend unit tests for new APIs.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/melonjs/src/camera/camera2d.ts | Adds multi-camera API surface (viewport offset, zoom, projections) and updates visibility + draw pipeline. |
| packages/melonjs/src/renderable/container.js | Skips UI-only floating elements on non-default cameras; applies screen/world projections for floating draws. |
| packages/melonjs/src/renderable/renderable.js | Adds visibleInAllCameras flag used for multi-camera floating filtering. |
| packages/melonjs/src/renderable/imagelayer.js | Ensures background layers render in all cameras and compute parallax based on the active viewport (incl. zoom). |
| packages/melonjs/src/video/canvas/canvas_renderer.js | Applies projection matrices to Canvas via a 2D transform. |
| packages/melonjs/src/video/webgl/webgl_renderer.js | Flushes batches before switching projection matrices to avoid incorrect batched rendering. |
| packages/melonjs/tests/camera.spec.js | Expands camera coverage for zoom, projections, viewport offsets, and draw state restoration. |
| packages/melonjs/tests/renderable.spec.js | Adds tests for visibleInAllCameras. |
| packages/melonjs/tests/container.spec.js | Adds tests around floating renderables and multi-camera visibility defaults. |
| packages/melonjs/tests/bounds.spec.ts | Adds tests for Bounds.setMinMax. |
| packages/melonjs/CHANGELOG.md | Notes multi-camera support and Canvas projection behavior change. |
| packages/examples/src/examples/platformer/play.ts | Adds a minimap camera to the platformer stage. |
| packages/examples/src/examples/platformer/entities/minimap.ts | New minimap camera implementation + overlays. |
| packages/examples/src/examples/platformer/createGame.ts | Switches example renderer selection to video.AUTO. |
Comments suppressed due to low confidence (1)
packages/melonjs/src/camera/camera2d.ts:922
- For non-default cameras,
drawFX()is called while the renderer projection is stillworldProjection(and in Canvas modedrawFX()resets the transform to identity). This causes fade/flash overlays to render at the wrong scale/position (often not covering the camera viewport whenscreenX/screenYorzoomare set). Consider switching toscreenProjection(or drawing inwidth/zoomworld units) arounddrawFX()for non-default cameras, and ensure Canvas mode reapplies the projection afterresetTransform().
// draw the viewport/camera effects
this.drawFX(renderer);
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // add a second camera | ||
| this.cameras.set("minimap", new MinimapCamera()); |
There was a problem hiding this comment.
Adding a new MinimapCamera() unconditionally in onResetEvent can leak/accumulate cameras across state transitions: Stage.reset() does not clear this.cameras, and Stage.destroy() only clears the map without destroying cameras. Consider guarding against duplicates (e.g. reuse an existing minimap camera if already present) or explicitly removing/destroying the previous instance before overwriting the key.
| // add a second camera | |
| this.cameras.set("minimap", new MinimapCamera()); | |
| // add a second camera (reuse if it already exists) | |
| let minimapCamera = this.cameras.get("minimap") as MinimapCamera | undefined; | |
| if (!minimapCamera) { | |
| minimapCamera = new MinimapCamera(); | |
| this.cameras.set("minimap", minimapCamera); | |
| } |
| // reposition on canvas resize (keep anchored to top-right) | ||
| event.on(event.CANVAS_ONRESIZE, (w: number) => { | ||
| this.screenX = w - MINIMAP_WIDTH - 10; | ||
| }); |
There was a problem hiding this comment.
This event.on(CANVAS_ONRESIZE, ...) subscription is never removed. Since stages/cameras can be reset multiple times, this can leave stale listeners updating orphaned cameras. Consider storing the listener function and calling event.off(...) in an onDestroyEvent()/destroy() override (or otherwise ensuring only one listener is registered).
| renderer.setColor("#ffffff"); | ||
| renderer.lineWidth = 1.5 * screenPx; | ||
| renderer.strokeRect(view.left, view.top, view.width, view.height); |
There was a problem hiding this comment.
renderer.lineWidth is modified here, but in WebGL the renderer save/restore stack does not track lineWidth, so this value can leak into subsequent draws even after super.postDraw() restores. Consider saving the previous lineWidth and restoring it before returning (or explicitly resetting it after drawing the minimap overlays).
| this.screenY = y; | ||
| if (typeof w !== "undefined" && typeof h !== "undefined") { | ||
| super.resize(w, h); | ||
| this._updateProjectionMatrix(); |
There was a problem hiding this comment.
setViewport() resizes the camera rect via super.resize(w, h) but does not update size-dependent state like the deadzone (used by follow logic). After changing w/h, the existing deadzone may no longer fit the camera and can cause incorrect follow behavior. Consider updating the deadzone (and any other size-derived values) when w/h are provided, without resetting bounds like resize() does.
| this._updateProjectionMatrix(); | |
| this._updateProjectionMatrix(); | |
| // keep size-dependent state (like the follow deadzone) consistent | |
| // with the new viewport size without resetting camera bounds | |
| if (this.deadzone != null) { | |
| // ensure the deadzone rectangle still fits inside the camera | |
| const dz = this.deadzone; | |
| if (dz.width > this.width || dz.height > this.height) { | |
| // shrink the deadzone to at most the camera size, preserving its origin | |
| dz.width = Math.min(dz.width, this.width); | |
| dz.height = Math.min(dz.height, this.height); | |
| } | |
| } |
| this._worldView.setMinMax( | ||
| this.pos.x, | ||
| this.pos.y, | ||
| this.pos.x + this.width / this.zoom, | ||
| this.pos.y + this.height / this.zoom, |
There was a problem hiding this comment.
worldView (and the non-default camera projection math) divides by this.zoom, but zoom is a plain public number with no validation. Setting zoom to 0 (or a negative value) will yield infinities/NaNs in worldView and ortho() and can break rendering and visibility checks. Consider adding a getter/setter for zoom that clamps to a small positive value or throws on invalid input.
| this._worldView.setMinMax( | |
| this.pos.x, | |
| this.pos.y, | |
| this.pos.x + this.width / this.zoom, | |
| this.pos.y + this.height / this.zoom, | |
| // Guard against invalid zoom values (0, NaN, Infinity) to avoid | |
| // infinities/NaNs in the computed bounds. | |
| let safeZoom = this.zoom; | |
| if (!Number.isFinite(safeZoom) || safeZoom === 0) { | |
| safeZoom = 1; | |
| } | |
| this._worldView.setMinMax( | |
| this.pos.x, | |
| this.pos.y, | |
| this.pos.x + this.width / safeZoom, | |
| this.pos.y + this.height / safeZoom, |
- Guard against duplicate minimap camera in onResetEvent - Store and cleanup CANVAS_ONRESIZE listener in minimap destroy() - Save/restore lineWidth in minimap postDraw - Validate zoom setter to reject zero/negative values (fall back to 1) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
screenX,screenY,zoom,autoResize,isDefault,worldView,setViewport(),worldProjection,screenProjection)visibleInAllCamerasto Renderable for per-camera floating element filteringsetProjection()in CanvasRenderer (ortho matrix → canvas 2D transform)setProjection()to prevent batched quads rendering with wrong projectionTest plan
🤖 Generated with Claude Code